/*
    Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you under the Apache License, Version 2.0 (the
    "License"); you may not use this file except in compliance
    with the License.  You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing,
    software distributed under the License is distributed on an
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
    under the License.
 */
package erwiki.part.rendering.internal.parser;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.EmptyStackException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Stack;

import javax.xml.transform.Result;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.log4j.Logger;
import org.apache.oro.text.GlobCompiler;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternCompiler;
import org.elwiki_data.WikiPage;
import org.jdom2.Attribute;
import org.jdom2.Content;
import org.jdom2.Element;
import org.jdom2.IllegalDataException;
import org.jdom2.ProcessingInstruction;
import org.jdom2.Text;
import org.jdom2.Verifier;

import erwiki.LinkCollector;
import erwiki.StringTransmutator;
import erwiki.api.attachment.AttachmentManager;
import erwiki.api.auth.AccountRegistry;
import erwiki.api.auth.AuthorizationManager;
import erwiki.api.component.IPartPreferences;
import erwiki.api.core.ContextEnum;
import erwiki.api.core.WikiContext;
import erwiki.api.exceptions.InternalWikiException;
import erwiki.api.exceptions.PluginException;
import erwiki.api.parser.Heading;
import erwiki.api.parser.HeadingListener;
import erwiki.api.parser.LinkParser;
import erwiki.api.parser.MarkupParser;
import erwiki.api.parser.ParseException;
import erwiki.api.parser.WikiDocument;
import erwiki.api.preferences.Preferences;
import erwiki.api.rwc.RWC;
import erwiki.api.variables.VariableManager;
import erwiki.util.TextUtil;
import erwiki.util.XmlUtil;

/**
 * Преобразует разметку представленную в стиле JSPWiki в DOM дерево WikiDocument.</br>
 * Этот класс – сердце и душа Wiki. Убедитесь, что успешно тестируете все, что добавляется. Иначе,
 * ужасно будет, если он сломается.
 */
public class JSPWikiMarkupParser extends MarkupParser implements IPartPreferences {

	public enum LinkType {//@formatter:off
		EMPTY(""), // Empty message
		@Deprecated
		CMD(CLASS_WIKIPAGE),
		READ(CLASS_WIKIPAGE),
		//:FVK: READ_ID(CLASS_WIKIPAGE),
		CREATE(CLASS_CREATEPAGE),
		LOCAL(CLASS_FOOTNOTE),
		LOCALREF(CLASS_FOOTNOTE_REF),
		IMAGE(""),
		EXTERNAL(CLASS_EXTERNAL),
		INTERWIKI(CLASS_INTERWIKI),
		IMAGELINK(CLASS_EXTERNAL),
		IMAGEWIKILINK(CLASS_WIKIPAGE),
		ATTACHMENT(CLASS_ATTACHMENT);//@formatter:on

		private String classType;

		private LinkType(String classType) {
			this.classType = classType;
		}

		public String getClassType() {
			return this.classType;
		}
	};

	protected interface Prefs {

		/**
		 * Если установлено значение "true", то ко всем внешним ссылкам (external links) будет добавлено
		 * небольшое изображение ссылки.
		 */
		String USE_OUTLINK_IMAGE = "translatorReader.useOutlinkImage";

		/**
		 * Если установлено значение "true", то ко всем ссылкам с информацией о внешних вложениях будет
		 * добавлено небольшое изображение.
		 */
		String USE_ATTACHMENT_IMAGE = "translatorReader.useAttachmentImage";

		/**
		 * Если установлено значение "true", то разрешается использовать необработанный HTML в тексте Wiki.
		 * Имейте в виду, это ОЧЕНЬ опасная опция – никогда не включайте ее в общедоступной Wiki, если вы не
		 * уверены абсолютно в том, что делаете.
		 */
		String ALLOW_RAW_HTML = "translatorReader.allowRawHTML";

		/** Если установлено значение "true", все внешние ссылки помечаются тегом 'rel="nofollow"'. */
		String USE_REL_NOFOLLOW = "translatorReader.useRelNofollow";

		/**
		 * Если значение равно "true", то все гиперссылки также транслируются, независимо от того, заключены
		 * ли они в скобки или нет.
		 */
		String PLAIN_URIS = "translatorReader.plainUris";

		/** Узел свойств для набора шаблонов встроенных изображений. */
		String NODE_INLINE_PATTERNS = "node.translatorReader.inlinePatterns";
	}

	private static final String INLINE_IMAGE_PATTERNS = "JSPWikiMarkupParser.inlineImagePatterns";

	private static final Logger log = Logger.getLogger(JSPWikiMarkupParser.class);

	private boolean m_isbold = false;
	private boolean m_isitalic = false;
	private boolean m_istable = false;
	private boolean m_isPre = false;
	private boolean m_isEscaping = false;
	private boolean m_isdefinition = false;
	private boolean m_isPreBlock = false;

	/** Contains style information, in multiple forms. */
	private Stack<Boolean> m_styleStack = new Stack<>();

	// general list handling
	private int m_genlistlevel = 0;
	private StringBuilder m_genlistBulletBuffer = new StringBuilder(10); // stores the # and * pattern
	private boolean m_allowPHPWikiStyleLists = true;

	private boolean m_isOpenParagraph = false;

	/** Parser for extended link functionality. */
	private LinkParser m_linkParser = new LinkParser();

	/** Накапливает любой простой текст, который затем помещается в html-элементы Text. */
	private StringBuilder m_plainTextBuf = new StringBuilder(20);

	private Element m_currentElement;

	/** Keep track of duplicate header names. */
	private Map<String, Integer> m_titleSectionCounter = new HashMap<>();

	/** If true, then generate special output for wysiwyg editing in certain cases */
	private boolean m_wysiwygEditorMode = false;

	/** Если "true", то рассматриваются также URI без скобок. */
	// FIXME: Currently reserved, but not used.
	@SuppressWarnings("unused")
	private boolean m_plainUris = false;

	/** Если "true", то всем внешним ссылкам добавляется небольшое изображение ссылки. */
	private boolean m_useOutlinkImage = true;

	private boolean m_useAttachmentImage = true;

	/** Если "true", то допускается необработанный (raw) HTML. */
	private boolean m_allowHTML = false;

	private boolean m_useRelNofollow = false;

	private int m_rowNum = 1;

	private Heading m_lastHeading = null;

	private AccountRegistry accountRegistry;

	private AuthorizationManager authorizationManager;

	private AttachmentManager attachmentManager;

	private VariableManager variableManager;

	/**
	 * Создает экземпляр анализатора разметки.
	 *
	 * @param in Откуда считываются данные.
	 */
	public JSPWikiMarkupParser(Reader in) {
		super(in);
		accountRegistry = this.m_engine.getManager(AccountRegistry.class);
		authorizationManager = this.m_engine.getManager(AuthorizationManager.class);
		attachmentManager = this.m_engine.getManager(AttachmentManager.class);
		variableManager = this.m_engine.getManager(VariableManager.class);
		initialize();
	}

	// FIXME: parsers should be pooled for better performance.
	private void initialize() {
		RWC ctx = RWC.INSTANCE;
		Boolean wysiwygVariable = ctx.getVariable(WikiContext.VAR_WYSIWYG_EDITOR_MODE);
		if (wysiwygVariable != null) {
			m_wysiwygEditorMode = wysiwygVariable;
		}

		m_useOutlinkImage = getPreference(Prefs.USE_OUTLINK_IMAGE, Boolean.class);
		m_useAttachmentImage = getPreference(Prefs.USE_ATTACHMENT_IMAGE, Boolean.class);
		m_allowHTML = getPreference(Prefs.ALLOW_RAW_HTML, Boolean.class);
		m_useRelNofollow = getPreference(Prefs.USE_REL_NOFOLLOW, Boolean.class);
		m_plainUris = getPreference(Prefs.PLAIN_URIS, Boolean.class);

		if (accountRegistry == null || authorizationManager == null) {
			disableAccessRules();
		}

		// :FVK: m_context.getPage().setHasMetadata();
	}

	@Override
	protected final List<Pattern> initInlineImagePatterns() {
		PatternCompiler compiler = new GlobCompiler();

		// Скомпилированные паттерны кешируются в Engine, так как их создание очень дорогое удовольствие.
		List<Pattern> compiledpatterns = m_engine.getAttribute(INLINE_IMAGE_PATTERNS);

		if (compiledpatterns == null) {
			compiledpatterns = new ArrayList<>(20);
			Collection<String> ptrns = getAllInlinedImagePatterns();

			// Компилирование паттернов в регулярные выражения. Неизвестные шаблоны игнорируются.
			for (String pattern : ptrns) {
				try {
					compiledpatterns
							.add(compiler.compile(pattern, GlobCompiler.DEFAULT_MASK | GlobCompiler.READ_ONLY_MASK));
				} catch (MalformedPatternException e) {
					log.error("Malformed pattern [" + pattern + "] in properties: ", e);
				}
			}

			m_engine.setAttribute(INLINE_IMAGE_PATTERNS, compiledpatterns);
		}

		return Collections.unmodifiableList(compiledpatterns);
	}

	/**
	 * Возвращает коллекцию всех типов встраиваемых изображений.
	 *
	 * @return Коллекция строк с шаблонами регулярных выражении.
	 */
	protected Collection<String> getAllInlinedImagePatterns() {
		String patternsSeq = getPreference(Prefs.NODE_INLINE_PATTERNS, String.class);
		return Arrays.asList(patternsSeq.split(","));
	}

	/**
	 * Вызывает цепочку трансмутаторов.
	 *
	 * @param list Цепочка для вызова.
	 * @param text Текст, который передается методу mutate() каждому мутатору в цепочке.
	 * @return Результат мутации.
	 */
	protected String callMutatorChain(Collection<StringTransmutator> list, String text) {
		if (list == null || list.size() == 0) {
			return text;
		}

		for (StringTransmutator m : list) {
			text = m.mutate(text);
		}

		return text;
	}

	/**
	 * Calls a link collectors chain.
	 *
	 * @param collectors Chain to call.
	 * @param text       Text that should be passed to the collec() method of each of the collectors in
	 *                   the chain.
	 */
	protected void collectLink(Collection<LinkCollector> collectors, String text) {
		if (collectors == null) {
			return;
		}

		for (LinkCollector collector : collectors) {
			text = collector.collect(text);
		}
	}

	/**
	 * Calls the heading listeners.
	 *
	 * @param heading A Heading object.
	 */
	protected void callHeadingListenerChain(Heading heading) {
		List<HeadingListener> list = m_headingListenerChain;
		for (HeadingListener h : list) {
			h.headingAdded(heading);
		}
	}

	/**
	 * Создает элемент привязки JDOM. Можно переопределить, чтобы изменить создание URL-адреса, если
	 * действительно знаете что делаете.
	 *
	 * @param type    Один из типов выше.
	 * @param link    URL-адрес, на который будет сделана ссылка.
	 * @param text    Текст ссылки.
	 * @param section Если требуется конкретный идентификатор раздела.
	 * @return Элемент A.
	 */
	protected Element createAnchor(LinkType type, String link, String text, String section) {
		text = escapeHTMLEntities(text);
		section = escapeHTMLEntities(section);
		Element el = new Element("a");
		el.setAttribute("class", type.getClassType());
		el.setAttribute("href", link + section);
		el.addContent(text);
		return el;
	}

	/**
	 * @param type       Тип ссылки: IMAGEWIKILINK, ...
	 * @param linkHref   Поле 'href' тега &lt;a&gt; ... &lt;/a&gt;.
	 * @param text       Текст тега &lt;a&gt;. Возможно, это <code>null</code>, тогда в данном случае
	 *                   этот текст заменяется значением параметра link.
	 * @param section    Идентификатор раздела страницы, может быть <code>null</code>.
	 * @param attributes Атрибуты ссылки, для html-элемента.
	 * @return
	 * @throws IOException
	 */
	private Element makeLink(LinkType type, String linkHref, String text, String section,
			Iterator<Attribute> attributes) throws IOException {
		Element el = null;

		if (text == null) {
			text = linkHref;
		}

		text = callMutatorChain(m_linkMutators, text);

		section = (section != null) ? ("#" + section) : "";

		// Следует убедиться, что создано имя ссылки, которое может быть принято как допустимый URL-адрес.

		if (linkHref.length() == 0) {
			type = LinkType.EMPTY;
		}
		ResourceBundle rb = Preferences.getBundle();

		WikiContext ctx = RWC.INSTANCE;

		//@formatter:off
		el = switch (type) {
		/*:FVK: case READ_ID -> createAnchor( LinkType.READ, m_context.getURL( WikiContext.PAGE_VIEWID, link), text, section ); */
		case CMD -> createAnchor(LinkType.CMD, m_engine.getURL(linkHref, "", null), text, section);
		case READ -> createAnchor(LinkType.READ, "erwiki://view?pgid=" + linkHref, text, section);
		//:FVK:old code: createAnchor(LinkType.READ, m_context.getURL(WikiContext.PAGE_VIEW, link), text, section);
		case CREATE -> {
		//:FVK:old code: createAnchor(LinkType.CREATE, engine.getURL(WikiContext.PAGE_CREATE, link, null), text, "")
			String name = (StringUtils.isNotBlank(text))? "&name=" + m_engine.encodeName(text) : "";
			String href =  "erwiki://create?pgid=" + linkHref + name;
			yield createAnchor(LinkType.CREATE, href, text, "")
				.setAttribute("title", MessageFormat.format(rb.getString("markupparser.link.create"), text));
		}
		case EMPTY -> new Element("u").addContent(text);
		//
		// Эти два случая предназначены для ссылок на сноски и локальные ссылки-сноски.
		// Встраивается имя страницы (или то, что выдает WikiContext),
		// чтобы гарантировать уникальность ссылок в Wiki.
		//
		case LOCAL -> new Element("a").setAttribute("class", CLASS_FOOTNOTE)
			.setAttribute("name", "ref-" + ctx.getName() + "-" + linkHref.substring(1))
			.addContent("[" + text + "]");
		case LOCALREF -> createAnchor(LinkType.LOCALREF,
				"#ref-" + ctx.getName() + "-" + linkHref, "[" + text + "]", "");
		//
		// С изображениями, внешними ссылками и интервики - нужно убедиться,
		// что никто не сможет вставить Javascript или что-то еще раздражающее в сами ссылки.
		// Мы делаем это, запрещая хакеру закрывать имя ссылки короткими кавычками в fillBuffer().
		//
		case IMAGE -> new Element("img").setAttribute("class", "inline")
				.setAttribute("src", linkHref).setAttribute("alt", text);
		case IMAGELINK -> createAnchor(LinkType.IMAGELINK, text, "", "").addContent(
				new Element("img").setAttribute("class", "inline")
					.setAttribute("src", linkHref).setAttribute("alt", text)
				);
		case IMAGEWIKILINK -> createAnchor(
				LinkType.IMAGEWIKILINK,
				m_engine.getURL(WikiContext.PAGE_VIEW, text, null),
				"", "")
			.addContent(
				new Element("img").setAttribute("class", "inline")
				.setAttribute("src", linkHref).setAttribute("alt", text)
				);
		case EXTERNAL -> {
			Element el1 = createAnchor(LinkType.EXTERNAL, linkHref, text, section);
			if (m_useRelNofollow) {
				el1.setAttribute("rel", "nofollow");
			}
			yield el1;
		}
		case INTERWIKI -> createAnchor(LinkType.INTERWIKI, linkHref, text, section);
		case ATTACHMENT -> {
			Element el1;
			String attlink = m_engine.getURL(WikiContext.ATTACHMENT_DOGET, linkHref, null);
			String infolink = m_engine.getURL(WikiContext.PAGE_INFO, linkHref, null);
			String imglink = m_engine.getURL(WikiContext.PAGE_NONE, "images/attachment_small.png", null);
			el1 = createAnchor(LinkType.ATTACHMENT, attlink, text, "");

			if (attachmentManager.forceDownload(attlink)) {
				el1.setAttribute("download", "");
			}

			pushElement(el1);
			popElement(el1.getName());

			if (m_useAttachmentImage) {
				el1 = new Element("img").setAttribute("src", imglink);
				el1.setAttribute("border", "0");
				el1.setAttribute("alt", "(info)");

				el1 = new Element("a").setAttribute("href", infolink).addContent(el1);
				el1.setAttribute("class", "infolink");
			} else {
				el1 = null;
			}
			yield el1;
		}
		default -> throw new IllegalArgumentException("Unexpected value: " + type);
		};
		//@formatter:on

		if (el != null && attributes != null) {
			while (attributes.hasNext()) {
				Attribute attr = attributes.next();
				if (attr != null) {
					el.setAttribute(attr);
				}
			}
		}

		if (el != null) {
			flushPlainText();
			m_currentElement.addContent(el);
		}

		return el;
	}

	/**
	 * Это все элементы блочного уровня HTML 4.01.
	 */
	private static final String[] BLOCK_ELEMENTS = { "address", "blockquote", "div", "dl", "fieldset", "form", "h1",
			"h2", "h3", "h4", "h5", "h6", "hr", "noscript", "ol", "p", "pre", "table", "ul" };

	private static boolean isBlockLevel(String name) {
		return Arrays.binarySearch(BLOCK_ELEMENTS, name) >= 0;
	}

	/**
	 * Этот метод просматривает поток до EOL и возвращает результат. Он сохраняет буферы нетронутыми.
	 *
	 * @return Строку от текущей позиции до конца строки.
	 */
	// FIXME: Always returns an empty line, even if the stream is full.
	private String peekAheadLine() throws IOException {
		String s = readUntilEOL().toString();

		if (s.length() > PUSHBACK_BUFFER_SIZE) {
			log.warn("Line is longer than maximum allowed size (" + PUSHBACK_BUFFER_SIZE
					+ " characters.  Attempting to recover...");
			pushBack(s.substring(0, PUSHBACK_BUFFER_SIZE - 1));
		} else {
			try {
				pushBack(s);
			} catch (IOException e) {
				log.warn("Pushback failed: the line is probably too long.  Attempting to recover.");
			}
		}
		return s;
	}

	private int flushPlainText() {
		int numChars = m_plainTextBuf.length();

		if (numChars > 0) {
			String buf;

			if (!m_allowHTML) {
				buf = escapeHTMLEntities(m_plainTextBuf.toString());
			} else {
				buf = m_plainTextBuf.toString();
			}

			//
			// Необходимо очистить буфер, потому что побочным эффектом работы некоторых методов парсера
			// - является вызов этой процедуры. Например, handleOpenbracket().
			//
			m_plainTextBuf = new StringBuilder(20);

			try {
				// просто добавить элементы.
				m_currentElement.addContent(buf);
			} catch (IllegalDataException e) {
				//
				// Иногда возможно, что к данным будут добавлены недопустимые символы XML.
				// Здесь обеспечивается, чтобы парсинг не останавливался.
				//
				m_currentElement.addContent(makeError(cleanupSuspectData(e.getMessage())));
			}
		}

		return numChars;
	}

	/**
	 * Экранирует XML сущности HTML-совместимым способом (т.е. не экранирует объекты, которые уже
	 * оформлены).
	 *
	 * @param buf Входные данные.
	 * @return Экранированная строка.
	 */
	private String escapeHTMLEntities(String buf) {
		StringBuilder tmpBuf = new StringBuilder(buf.length() + 20);

		for (int i = 0; i < buf.length(); i++) {
			char ch = buf.charAt(i);

			if (ch == '<') {
				tmpBuf.append("&lt;");
			} else if (ch == '>') {
				tmpBuf.append("&gt;");
			} else if (ch == '\"') {
				tmpBuf.append("&quot;");
			} else if (ch == '&') {
				//
				// Если далее идет ссылка на XML сущность (&#.*;), то мы
				// оставим все как есть; в противном случае заменяем на &amp;
				//
				boolean isEntity = false;
				StringBuilder entityBuf = new StringBuilder();

				if (i < buf.length() - 1) {
					for (int j = i; j < buf.length(); j++) {
						char ch2 = buf.charAt(j);

						if (Character.isLetterOrDigit(ch2) || (ch2 == '#' && j == i + 1) || ch2 == ';' || ch2 == '&') {
							entityBuf.append(ch2);

							if (ch2 == ';') {
								isEntity = true;
								break;
							}
						} else {
							break;
						}
					}
				}

				if (isEntity) {
					tmpBuf.append(entityBuf);
					i = i + entityBuf.length() - 1;
				} else {
					tmpBuf.append("&amp;");
				}

			} else {
				tmpBuf.append(ch);
			}
		}

		return tmpBuf.toString();
	}

	private Element pushElement(Element e) {
		flushPlainText();
		m_currentElement.addContent(e);
		m_currentElement = e;

		return e;
	}

	private Element addElement(Content e) {
		if (e != null) {
			flushPlainText();
			m_currentElement.addContent(e);
		}
		return m_currentElement;
	}

	/**
	 * Все элементы, которые могут быть пустыми согласно HTML DTD.
	 */
	// Keep sorted.
	private static final String[] EMPTY_ELEMENTS = { "area", "base", "br", "col", "hr", "img", "input", "link", "meta",
			"p", "param" };

	/**
	 * Проходит через текущий стек элементов и извлекает все элементы, пока этот элемент не будет найден
	 * – это по сути "закрывает" и элемент.
	 *
	 * @param s
	 * @return Новый текущий элемент или значение <code>null</code>, если такого элемента не было в
	 *         стеке.
	 */
	private Element popElement(String s) {
		int flushedBytes = flushPlainText();

		Element currEl = m_currentElement;

		while (currEl.getParentElement() != null) {
			if (currEl.getName().equals(s) && !currEl.isRootElement()) {
				m_currentElement = currEl.getParentElement();

				//
				// Проверить, можно ли, чтобы этот элемент был пустым.
				// Затем мы обманом заставим генератор JDOM не генерировать пустой элемент,
				// поместив пустую строку между тегами.
				// Да, это клудж, но что с этим делать. :-)
				//

				if (flushedBytes == 0 && Arrays.binarySearch(EMPTY_ELEMENTS, s) < 0) {
					currEl.addContent("");
				}

				return m_currentElement;
			}

			currEl = currEl.getParentElement();
		}

		return null;
	}

	/**
	 * Считывает поток до тех пор, пока не встретится один из указанных конечных символов или конец
	 * потока. Последний символ останется в потоке.
	 */
	private String readUntil(String endChars) throws IOException {
		StringBuilder sb = new StringBuilder(80);
		int ch = nextToken();

		while (ch != -1) {
			if (ch == '\\') {
				ch = nextToken();
				if (ch == -1) {
					break;
				}
			} else {
				if (endChars.indexOf((char) ch) != -1) {
					pushBack(ch);
					break;
				}
			}
			sb.append((char) ch);
			ch = nextToken();
		}

		return sb.toString();
	}

	/**
	 * Считывает поток, пока в потоке находятся указанные символы, а затем возвращает результат в виде
	 * строки.
	 */
	private String readWhile(String endChars) throws IOException {
		StringBuilder sb = new StringBuilder(80);
		int ch = nextToken();

		while (ch != -1) {
			if (endChars.indexOf((char) ch) == -1) {
				pushBack(ch);
				break;
			}

			sb.append((char) ch);
			ch = nextToken();
		}

		return sb.toString();
	}

	private JSPWikiMarkupParser m_cleanTranslator;

	/**
	 * Выполняет ленивую инициализацию.</br>
	 * В противном случае мы попадем в ситуацию, когда HTMLRenderer попытается загрузить
	 * TranslatorReader до того, как TranslatorReader, в котором он содержится, включен.
	 */
	private JSPWikiMarkupParser getCleanTranslator() {
		if (m_cleanTranslator == null) {
			m_cleanTranslator = new JSPWikiMarkupParser(null);
			m_cleanTranslator.m_allowHTML = true;
		}

		return m_cleanTranslator;
	}

	/**
	 * Изменяет параметр "hd", чтобы он содержал правильные значения.</br>
	 * Поскольку тег "id" может содержать только [a-zA-Z0-9:_-], мы заменим %, после кодирования URL, на
	 * '_'.
	 * <p>
	 * Учитывает также повторяющиеся заголовки (= заголовки с похожим названием) и прикрепляет счетчик.
	 *
	 * @throws IOException
	 */
	private String makeHeadingAnchor(String baseName, String title, Heading hd) throws IOException {
		hd.m_titleText = title;
		title = MarkupParser.wikifyLink(title);
		hd.m_titleSection = this.m_engine.encodeName(title);

		if (m_titleSectionCounter.containsKey(hd.m_titleSection)) {
			Integer count = m_titleSectionCounter.get(hd.m_titleSection) + 1;
			m_titleSectionCounter.put(hd.m_titleSection, count);
			hd.m_titleSection += "-" + count;
		} else {
			m_titleSectionCounter.put(hd.m_titleSection, 1);
		}

		hd.m_titleAnchor = "section-" + this.m_engine.encodeName(baseName) + "-" + hd.m_titleSection;
		hd.m_titleAnchor = hd.m_titleAnchor.replace('%', '_');
		hd.m_titleAnchor = hd.m_titleAnchor.replace('/', '_');

		return hd.m_titleAnchor;
	}

	private String makeSectionTitle(String title) {
		title = title.trim();
		try {
			JSPWikiMarkupParser dtr = getCleanTranslator();
			dtr.setInputReader(new StringReader(title));
			WikiDocument doc = dtr.parse();

			return XmlUtil.extractTextFromDocument(doc);
		} catch (IOException e) {
			log.fatal("Title parsing not working", e);
			throw new InternalWikiException(
					"Xml text extraction not working as expected when cleaning title" + e.getMessage(), e);
		}
	}

	/**
	 * Возвращает XHTML для заголовка.
	 *
	 * @param level Уровень заголовка. См. {@link Heading}.
	 * @param title название для заголовка.
	 * @param hd    список, к которому следует добавить заголовок.
	 * @return Element, содержащий заголовок.
	 * @throws IOException если что-то пошло не так, как ожидалось.
	 */
	public Element makeHeading(int level, String title, Heading hd) throws IOException {
		Element el;
		String pageName = RWC.INSTANCE.getPage().getName();
		String outTitle = makeSectionTitle(title);
		hd.m_level = level;

		switch (level) {
		case Heading.HEADING_SMALL:
			el = new Element("h4").setAttribute("id", makeHeadingAnchor(pageName, outTitle, hd));
			break;

		case Heading.HEADING_MEDIUM:
			el = new Element("h3").setAttribute("id", makeHeadingAnchor(pageName, outTitle, hd));
			break;

		case Heading.HEADING_LARGE:
			el = new Element("h2").setAttribute("id", makeHeadingAnchor(pageName, outTitle, hd));
			break;

		default:
			throw new InternalWikiException("Illegal heading type " + level);
		}

		return el;
	}

	/** Сохраняет URL-адрес изображения на время работы этого парсера. */
	private String m_outlinkImageURL = null;

	/**
	 * Возвращает элемент для внешней ссылки на изображение (out.png).</br>
	 * Однако этот метод кэширует URL-адрес для всего времени жизни этого MarkupParser, потому что он
	 * широко используется, и в конечном итоге мы получим, возможно, сотни или тысячи ссылок на него...
	 * К тому же это намного быстрее.
	 *
	 * @return Элемент, содержащий HTML для исходящей ссылки на изображение.
	 */
	private Element outlinkImage() {
		Element el = null;

		if (m_useOutlinkImage) {
			if (m_outlinkImageURL == null) {
				m_outlinkImageURL = m_engine.getURL(WikiContext.PAGE_NONE, OUTLINK_IMAGE, null);
			}

			el = new Element("img").setAttribute("class", OUTLINK);
			el.setAttribute("src", m_outlinkImageURL);
			el.setAttribute("alt", "");
		}

		return el;
	}

	/**
	 * Ссылки на изображения обрабатываются по-разному:
	 * <ol>
	 * <li>Если текст является названием существующей страницы, на него будет дана ссылка.</li>
	 * <li>Если текст является внешней ссылкой, то он встраивается.</li>
	 * <li>В противном случае он становится ALT текстом ссылки.</li>
	 * </ol>
	 *
	 * @param reallink    Ссылка на изображение.
	 * @param link        Текстовая часть ссылки, может быть ссылкой на что-то еще.
	 * @param hasLinkText Если <i>true</i>, то для определенной ссылки был доступен текст ссылки. Это
	 *                    означает, что текст ссылки может быть ссылкой на вики-страницу или внешний
	 *                    ресурс.
	 * @throws IOException
	 */
	// FIXME: isExternalLink() is called twice.
	private Element handleImageLink(String reallink, String link, boolean hasLinkText) throws IOException {
		String possiblePage = MarkupParser.cleanLink(link);

		if (hasLinkText && m_linkParsingOperations.isExternalLink(link)) {
			return makeLink(LinkType.IMAGELINK, reallink, link, null, null);
		} else if (hasLinkText && m_linkParsingOperations.linkExists(possiblePage)) {
			// System.out.println("Orig="+link+", Matched: "+matchedLink);
			collectLink(m_localLinkCollectors, possiblePage);

			return makeLink(LinkType.IMAGEWIKILINK, reallink, link, null, null);
		} else {
			return makeLink(LinkType.IMAGE, reallink, link, null, null);
		}
	}

	/**
	 * Обрабатывает настройку метаданных, например, [{SET foo=bar}]
	 */
	private Element handleMetadata(String link) {
		if (m_wysiwygEditorMode) {
			m_currentElement.addContent("[" + link + "]");
		}

		try {
			String args = link.substring(link.indexOf(' '), link.length() - 1);
			String name = args.substring(0, args.indexOf('=')).trim();
			String val = args.substring(args.indexOf('=') + 1).trim();

			if (val.startsWith("'")) {
				val = val.substring(1);
			}
			if (val.endsWith("'")) {
				val = val.substring(0, val.length() - 1);
			}

			// log.debug("SET name='"+name+"', value='"+val+"'.");

			if (name.length() > 0 && val.length() > 0) {
				val = variableManager.expandVariables(val);
				RWC.INSTANCE.getPage().setAttribute(name, val);
			}
		} catch (Exception e) {
			ResourceBundle rb = Preferences.getBundle();
			return makeError(MessageFormat.format(rb.getString("markupparser.error.invalidset"), link));
		}

		return m_currentElement;
	}

	/**
	 * Выдает инструкцию обработки, которая отключает экранирование разметки.</br>
	 * Это очень полезно, если вы хотите редактировать HTML непосредственно в потоке.
	 */
	private void disableOutputEscaping() {
		addElement(new ProcessingInstruction(Result.PI_DISABLE_OUTPUT_ESCAPING, ""));
	}

	/**
	 * Обрабатывает все гиперссылки, заключенные в квадратные скобки.
	 */
	private Element handleHyperlinks(String linkText, int pos) {
		ResourceBundle rb = Preferences.getBundle();
		StringBuilder sb = new StringBuilder(linkText.length() + 80);

		if (m_linkParsingOperations.isMetadata(linkText)) {
			return handleMetadata(linkText);
		}

		if (m_linkParsingOperations.isPluginLink(linkText)) {
			PluginContent pluginContent = null;
			try {
				pluginContent = PluginContent.parsePluginLine(linkText, pos);

				// Иногда это может привести к сбою, особенно если есть что-то,
				// что выглядит как вызов плагина, но на самом деле таковым не является.
				if (pluginContent != null) {
					addElement(pluginContent);
					pluginContent.executeParse();
				}
			} catch (PluginException e) {
				WikiPage wikiPage = RWC.INSTANCE.getRealPage();
				log.info(wikiPage.getWiki() + " : " + wikiPage.getName() + " - Failed to insert plugin: "
						+ e.getMessage());

				if (pluginContent != null) {
					Element parentEl = pluginContent.getParent();
					if (parentEl != null) {
						// Удаление элемента PluginContent, чтобы он не вызывал ошибку.
						parentEl.removeContent(pluginContent);
						if (!m_wysiwygEditorMode) {
							// Добавление «красного» SPAN, который будет содержать сообщение об ошибке,
							// которое будет создано самим контентом плагина.
							parentEl.addContent(makeError(pluginContent));
						}
					}
				} else {
					// Проблема не была обнаружена. на этом уровне она не распознается.
					if (!m_wysiwygEditorMode) {
						WikiPage realPage = RWC.INSTANCE.getRealPage();
						String pattern = Preferences.getBundle().getString("markupparser.error.plugininsertion");
						return addElement(makeError(MessageFormat.format(pattern, //
								realPage.getWiki(), //
								realPage.getName(), //
								e.getMessage())));
					}
				}
			}

			return m_currentElement;
		}

		try {
			LinkParser.Link link = m_linkParser.parse(linkText);
			linkText = link.getText();
			String linkRef = link.getReference();

			//
			// Да, теперь мы разделили компоненты.
			// linktext = текст, который должна иметь ссылка
			// linkref = URL-адрес или @pageID, или имя вложения страницы.
			//
			// Во многих случаях это одно и то же. [linktext|linkref].
			//

			if (m_linkParsingOperations.isVariableLink(linkText)) {
				Content el = new VariableContent(linkText);

				addElement(el);
			} else if (m_linkParsingOperations.isExternalLink(linkRef)) {
				/// Это внешняя ссылка.

				collectLink(m_externalLinkCollectors, linkRef);

				if (isImageInlining() && m_linkParsingOperations.isImageLink(linkRef, getInlineImagePatterns())) {
					handleImageLink(linkRef, linkText, link.hasReference());
				} else {
					makeLink(LinkType.EXTERNAL, linkRef, linkText, null, link.getAttributes());
					addElement(outlinkImage());
				}
			} else if (link.isInterwikiLink()) {
				/// Это интервики-ссылка;
				/// InterWiki ссылки также добавляются во внешнюю цепочку ссылок
				/// после того, как они будут вычислены.

				/* FIXME: Здесь возникает интересная проблема:
				 * нам, вероятно, следует перекодировать URL-адрес вики-страницы,
				 * но мы не можем этого сделать, поскольку некоторые Wiki используют косые черты (/),
				 * которые не выдерживают кодировки URL.
				 * Кроме того, мы не знаем, какой набор символов используется в другая Wiki,
				 * поэтому вам придется написать полное название так, как оно указано в URL. Это отстой.
				 */

				String extWiki = link.getExternalWiki();
				String wikiPage = link.getExternalWikiPage();

				if (m_wysiwygEditorMode) {
					makeLink(LinkType.INTERWIKI, extWiki + ":" + wikiPage, linkText, null, link.getAttributes());
				} else {
					String urlReference = this.wikiConfig.getInterWikiURL(extWiki);

					if (urlReference != null) {
						urlReference = urlReference.replace("%s", wikiPage);
						collectLink(m_externalLinkCollectors, urlReference);

						if (m_linkParsingOperations.isImageLink(urlReference, getInlineImagePatterns())) {
							handleImageLink(urlReference, linkText, link.hasReference());
						} else {
							makeLink(LinkType.INTERWIKI, urlReference, linkText, null, link.getAttributes());
						}

						if (m_linkParsingOperations.isExternalLink(urlReference)) {
							addElement(outlinkImage());
						}
					} else {
						Object[] args = { escapeHTMLEntities(extWiki) };

						addElement(makeError(
								MessageFormat.format(rb.getString("markupparser.error.nointerwikiref"), args)));
					}
				}
			} else if (linkRef.startsWith("#")) {
				// Определяет локальную сноску. (local footnote)
				makeLink(LinkType.LOCAL, linkRef, linkText, null, link.getAttributes());
			} else if (TextUtil.isNumber(linkRef)) {
				// Определяет ссылку на локальную сноску. (local footnote)
				makeLink(LinkType.LOCALREF, linkRef, linkText, null, link.getAttributes());
			} else if (linkRef.matches("@cmd\\..+")) {
				/// Внутренняя вики-ссылка (по "команде" wiki).
				/// Работаем над ссылкой формата ElWiki.

				// LinkType linkType = LinkType.EMPTY;

				String cmdName = linkRef.substring(5); // :FVK: Workaround - length of prefix '@cmd.'
				String nameWikiContext = ContextEnum.getWikiContextName("cmd." + cmdName);
				makeLink(LinkType.CMD, nameWikiContext, linkText, null, link.getAttributes());
			} else if (linkRef.matches("^\s*?@.+")) {
				/// Внутренняя вики-ссылка (pageId, который может быть недействительным).
				/// Работаем над ссылкой формата ErWiki.
				LinkType linkType;
				String pageId = linkRef.trim().substring(1);
				String pageName = RWC.INSTANCE.getPageName(pageId);
				if (pageName != null) {
					if (!link.hasReference()) {
						linkText = pageName;
					}
					linkRef = pageId;
					linkType = LinkType.READ;
					collectLink(this.m_localLinkCollectors, linkRef);
				} else {
					/// Требуется создание страницы.
					WikiPage basePage = RWC.INSTANCE.getRealPage();
					linkRef = (basePage != null) ? basePage.getId() : "";
					linkType = LinkType.CREATE;
					collectLink(this.unknownPagesCollectors, linkText);
				}
				makeLink(linkType, linkRef, linkText, null, link.getAttributes());
			} else {
				/// Внутренняя вики-ссылка, но является ли она ссылкой прикрепления?
				int hashMark;
				String atchId = attachmentManager.getAttachmentId(linkRef);
				if (atchId != null) {
					/// Это ссылка прикрепления. (в ссылку помещается идентификатор прикрепления)
					collectLink(m_attachmentLinkCollectors, atchId);
					if (m_linkParsingOperations.isImageLink(linkRef, getInlineImagePatterns())) {
						String atchUrl = m_engine.getURL(WikiContext.ATTACHMENT_DOGET, "", "&atchid=" + atchId);
						sb.append(handleImageLink(atchUrl, linkText, link.hasReference()));
					} else {
						makeLink(LinkType.ATTACHMENT, atchId, linkText, null, link.getAttributes());
					}
				} else if ((hashMark = linkRef.indexOf('#')) != -1) {
					/// Это внутренняя Wiki-ссылка, но на именованный раздел.

					String namedSection = linkRef.substring(hashMark + 1);
					linkRef = linkRef.substring(0, hashMark);

					linkRef = MarkupParser.cleanLink(linkRef);

					collectLink(m_localLinkCollectors, linkRef);

					String matchedLink = m_linkParsingOperations.linkIfExists(linkRef);
					if (matchedLink != null) {
						String sectref = "section-"
								+ this.m_engine.encodeName(matchedLink + "-" + wikifyLink(namedSection));
						sectref = sectref.replace('%', '_');
						makeLink(LinkType.READ, matchedLink, linkText, sectref, link.getAttributes());
					} else {
						makeLink(LinkType.CREATE, linkRef, linkText, null, link.getAttributes());
					}
				} else {
					/// Это внутренняя Wiki-ссылка. (на страницу wiki)
					linkRef = MarkupParser.cleanLink(linkRef);

					collectLink(m_localLinkCollectors, linkRef);

					String matchedLink = m_linkParsingOperations.linkIfExists(linkRef);
					if (matchedLink != null) {
						makeLink(LinkType.READ, matchedLink, linkText, null, link.getAttributes());
					} else {
						makeLink(LinkType.CREATE, linkRef, linkText, null, link.getAttributes());
					}
				}
			}
		} catch (ParseException | IOException e) {
			log.info("Parser failure: ", e);
			Object[] args = { e.getMessage() };
			addElement(makeError(MessageFormat.format(rb.getString("markupparser.error.parserfailure"), args)));
		}
		return m_currentElement;
	}

	private Element handleBackslash() throws IOException {
		int ch = nextToken();

		if (ch == '\\') {
			int ch2 = nextToken();

			if (ch2 == '\\') {
				pushElement(new Element("br").setAttribute("clear", "all"));
				return popElement("br");
			}

			pushBack(ch2);

			pushElement(new Element("br"));
			return popElement("br");
		}

		pushBack(ch);

		return null;
	}

	private Element handleUnderscore() throws IOException {
		int ch = nextToken();
		Element el = null;

		if (ch == '_') {
			if (m_isbold) {
				el = popElement("b");
			} else {
				el = pushElement(new Element("b"));
			}
			m_isbold = !m_isbold;
		} else {
			pushBack(ch);
		}

		return el;
	}

	/**
	 * Например: <i>курсив</i> (<i>italics</i>).
	 */
	private Element handleApostrophe() throws IOException {
		int ch = nextToken();
		Element el = null;

		if (ch == '\'') {
			if (m_isitalic) {
				el = popElement("i");
			} else {
				el = pushElement(new Element("i"));
			}
			m_isitalic = !m_isitalic;
		} else {
			pushBack(ch);
		}

		return el;
	}

	private Element handleOpenbrace(boolean isBlock) throws IOException {
		int ch = nextToken();

		if (ch == '{') {
			int ch2 = nextToken();

			if (ch2 == '{') {
				m_isPre = true;
				m_isEscaping = true;
				m_isPreBlock = isBlock;

				if (isBlock) {
					startBlockLevel();
					return pushElement(new Element("pre"));
				}

				return pushElement(new Element("span").setAttribute("class", "inline-code"));
			}

			pushBack(ch2);

			return pushElement(new Element("tt"));
		}

		pushBack(ch);

		return null;
	}

	/**
	 * Обрабатывает как }}, так и }}}
	 */
	private Element handleClosebrace() throws IOException {
		int ch2 = nextToken();

		if (ch2 == '}') {
			int ch3 = nextToken();

			if (ch3 == '}') {
				if (m_isPre) {
					if (m_isPreBlock) {
						popElement("pre");
					} else {
						popElement("span");
					}

					m_isPre = false;
					m_isEscaping = false;
					return m_currentElement;
				}

				m_plainTextBuf.append("}}}");
				return m_currentElement;
			}

			pushBack(ch3);

			if (!m_isEscaping) {
				return popElement("tt");
			}
		}

		pushBack(ch2);

		return null;
	}

	private Element handleDash() throws IOException {
		int ch = nextToken();

		if (ch == '-') {
			int ch2 = nextToken();

			if (ch2 == '-') {
				int ch3 = nextToken();

				if (ch3 == '-') {
					/// Удалить все остальные тире.
					/// Не забыть вернуть первый не совпадающий символ обратно.
					do {
						ch = nextToken();
					} while (ch == '-');

					pushBack(ch);
					startBlockLevel();
					pushElement(new Element("hr"));
					return popElement("hr");
				}

				pushBack(ch3);
			}
			pushBack(ch2);
		}

		pushBack(ch);

		return null;
	}

	private Element handleHeading() throws IOException {
		Element el = null;

		int ch = nextToken();

		Heading hd = new Heading();

		if (ch == '!') {
			int ch2 = nextToken();

			if (ch2 == '!') {
				String title = peekAheadLine();

				el = makeHeading(Heading.HEADING_LARGE, title, hd);
			} else {
				pushBack(ch2);
				String title = peekAheadLine();
				el = makeHeading(Heading.HEADING_MEDIUM, title, hd);
			}
		} else {
			pushBack(ch);
			String title = peekAheadLine();
			el = makeHeading(Heading.HEADING_SMALL, title, hd);
		}

		callHeadingListenerChain(hd);

		m_lastHeading = hd;

		if (el != null) {
			pushElement(el);
		}

		return el;
	}

	/**
	 * Читает поток до следующего EOL или EOF. Обратите внимание, что будет читаться EOL из потока.
	 */
	private StringBuilder readUntilEOL() throws IOException {
		int ch;
		StringBuilder buf = new StringBuilder(256);

		while (true) {
			ch = nextToken();

			if (ch == -1) {
				break;
			}

			buf.append((char) ch);

			if (ch == '\n') {
				break;
			}
		}
		return buf;
	}

	/** Определяет, будет ли курсив рестартовать после сдвига абзаца (параграфа). */
	private boolean m_restartitalic = false;
	private boolean m_restartbold = false;

	private boolean m_newLine;

	/**
	 * Стартует элемент уровня блока, тем самым закрывая потенциально открытый тег абзаца (параграфа).
	 */
	private void startBlockLevel() {
		/// Они не могут превышать ограничения уровня блока в XHTML.

		popElement("i");
		popElement("b");
		popElement("tt");

		if (m_isOpenParagraph) {
			m_isOpenParagraph = false;
			popElement("p");
			m_plainTextBuf.append("\n"); // Просто небольшое украшение.
		}

		m_restartitalic = m_isitalic;
		m_restartbold = m_isbold;

		m_isitalic = false;
		m_isbold = false;
	}

	private static String getListType(char c) {
		if (c == '*') {
			return "ul";
		} else if (c == '#') {
			return "ol";
		}
		throw new InternalWikiException("Parser got faulty list type: " + c);
	}

	/**
	 * Подобно оригинальным handleOrderedList() и handleUnorderedList(), однако обрабатывает оба
	 * упорядоченных ('#'), и неупорядоченные ('*'), смешанные вместе.
	 */

	// FIXME: Refactor this; it's a bit messy.

	private Element handleGeneralList() throws IOException {
		startBlockLevel();

		String strBullets = readWhile("*#");
		// String strBulletsRaw = strBullets; // чтобы узнать, что было оригинальным до замены стиля phpwiki
		int numBullets = strBullets.length();

		// Переопределить начальную часть шаблона маркера (bullet), чтобы она была похожа на предыдущую,
		// для имитации списка стиля PHPWiki.

		if (m_allowPHPWikiStyleLists) {
			// заменять только если отличается.
			if (!(strBullets.substring(0, Math.min(numBullets, m_genlistlevel))
					.equals(m_genlistBulletBuffer.substring(0, Math.min(numBullets, m_genlistlevel))))) {
				if (numBullets <= m_genlistlevel) {
					/// Заменить все символы, кроме последнего (keep the expressed bullet preference)
					strBullets = (numBullets > 1 ? m_genlistBulletBuffer.substring(0, numBullets - 1) : "")
							+ strBullets.substring(numBullets - 1, numBullets);
				} else {
					strBullets = m_genlistBulletBuffer + strBullets.substring(m_genlistlevel, numBullets);
				}
			}
		}

		//
		// Проверить, относится ли это все к тому же типу.
		//
		if (strBullets.substring(0, Math.min(numBullets, m_genlistlevel))
				.equals(m_genlistBulletBuffer.substring(0, Math.min(numBullets, m_genlistlevel)))) {
			if (numBullets > m_genlistlevel) {
				pushElement(new Element(getListType(strBullets.charAt(m_genlistlevel++))));

				for (; m_genlistlevel < numBullets; m_genlistlevel++) {
					// bullets are growing, get from new bullet list
					pushElement(new Element("li"));
					pushElement(new Element(getListType(strBullets.charAt(m_genlistlevel))));
				}
			} else if (numBullets < m_genlistlevel) {
				/// Закрыть предыдущий элемент списка.
				// buf.append( m_renderer.closeListItem() );
				popElement("li");

				for (; m_genlistlevel > numBullets; m_genlistlevel--) {
					// маркеры уменьшаются, взять из старого списка маркеров.
					popElement(getListType(m_genlistBulletBuffer.charAt(m_genlistlevel - 1)));
					if (m_genlistlevel > 0) {
						popElement("li");
					}
				}
			} else {
				if (m_genlistlevel > 0) {
					popElement("li");
				}
			}
		} else {
			//
			// Шаблон изменился, раскрутить и начать заново.
			//
			int numEqualBullets;
			int numCheckBullets;

			// узнать, насколько это одно и то же.
			numEqualBullets = 0;
			numCheckBullets = Math.min(numBullets, m_genlistlevel);

			while (numEqualBullets < numCheckBullets) {
				if (strBullets.charAt(numEqualBullets) == m_genlistBulletBuffer.charAt(numEqualBullets)) {
					// если маркеры (bullets) пока равны, то продолжить.
					numEqualBullets++;
				} else {
					// иначе прервать поиск, так как нашлось число равных.
					break;
				}
			}

			// раскрутить.
			for (; m_genlistlevel > numEqualBullets; m_genlistlevel--) {
				popElement(getListType(m_genlistBulletBuffer.charAt(m_genlistlevel - 1)));
				if (m_genlistlevel > numBullets) {
					popElement("li");
				}
			}

			// отмотать назад.

			pushElement(new Element(getListType(strBullets.charAt(numEqualBullets++))));
			for (int i = numEqualBullets; i < numBullets; i++) {
				pushElement(new Element("li"));
				pushElement(new Element(getListType(strBullets.charAt(i))));
			}
			m_genlistlevel = numBullets;
		}

		//
		// Вставить новый элемент списка, и убрать все лишние пробелы.
		//
		pushElement(new Element("li"));
		readWhile(" ");

		// работа выполнена, запомнить новый список bullet-маркеров (вместо старого)
		m_genlistBulletBuffer.setLength(0);
		m_genlistBulletBuffer.append(strBullets);

		return m_currentElement;
	}

	private Element unwindGeneralList() {
		// раскрутить.
		for (; m_genlistlevel > 0; m_genlistlevel--) {
			popElement("li");
			popElement(getListType(m_genlistBulletBuffer.charAt(m_genlistlevel - 1)));
		}

		m_genlistBulletBuffer.setLength(0);

		return null;
	}

	private Element handleDefinitionList() throws IOException {
		if (!m_isdefinition) {
			m_isdefinition = true;

			startBlockLevel();

			pushElement(new Element("dl"));
			return pushElement(new Element("dt"));
		}

		return null;
	}

	/**
	 * Обрабатывает содержимое, после открывающей скобки '['.
	 *
	 * @return Элемент, содержащий HTML для конструкции внутри '[ ]'.
	 * @throws IOException если что-то пошло не так, как ожидалось.
	 */
	private Element handleOpenbracket() throws IOException {
		StringBuilder sb = new StringBuilder(40);
		int pos = getPosition();
		int ch = nextToken();
		boolean isPlugin = false;

		if (ch == '[') {
			if (m_wysiwygEditorMode) {
				sb.append('[');
			}

			sb.append((char) ch);

			while ((ch = nextToken()) == '[') {
				sb.append((char) ch);
			}
		}

		if (ch == '{') {
			isPlugin = true;
		}

		pushBack(ch);

		if (sb.length() > 0) {
			m_plainTextBuf.append(sb);
			return m_currentElement;
		}

		//
		// Поиск конца гиперссылки.
		//
		int nesting = 1; // Для проверки наличия вложенных объявлений плагинов.
		while ((ch = nextToken()) != -1) {
			int ch2 = nextToken();
			pushBack(ch2);

			if (isPlugin) {
				if (ch == '[' && ch2 == '{') {
					nesting++;
				} else if (nesting == 0 && ch == ']' && sb.charAt(sb.length() - 1) == '}') {
					break;
				} else if (ch == '}' && ch2 == ']') {
					// В конце объявления плагина значение будет уменьшено до 0.
					nesting--;
				}
			} else {
				if (ch == ']') {
					break;
				}
			}

			sb.append((char) ch);
		}

		//
		// Если ссылка так и не завершится, то выполнить несколько трюков,
		// чтобы отобразить остальную часть строки без изменений.
		//
		if (ch == -1) {
			log.debug("Warning: unterminated link detected!");
			m_isEscaping = true;
			m_plainTextBuf.append(sb);
			flushPlainText();
			m_isEscaping = false;
			return m_currentElement;
		}

		return handleHyperlinks(sb.toString(), pos);
	}

	/**
	 * Считывает поток до тех пор, пока текущая скобка не будет закрыта или поток не закончится.
	 *
	 * @param opening Открывающая скобка.
	 * @param closing Закрывающая скобка.
	 * @return
	 * @throws IOException если что-то пошло не так, как ожидалось.
	 */
	private String readBraceContent(char opening, char closing) throws IOException {
		StringBuilder sb = new StringBuilder(40);
		int braceLevel = 1;
		int ch;
		while ((ch = nextToken()) != -1) {
			if (ch == '\\') {
				continue;
			} else if (ch == opening) {
				braceLevel++;
			} else if (ch == closing) {
				braceLevel--;
				if (braceLevel == 0) {
					break;
				}
			}
			sb.append((char) ch);
		}
		return sb.toString();
	}

	/**
	 * Обрабатывает конструкции типа %%(style) и %%class
	 *
	 * @param newLine
	 * @return Элемент, содержащий div или span, в зависимости от ситуации.
	 * @throws IOException если что-то пошло не так, как ожидалось.
	 */
	private Element handleDiv(boolean newLine) throws IOException {
		int ch = nextToken();
		Element el = null;

		if (ch == '%') {
			String style = null;
			String clazz = null;

			ch = nextToken();

			//
			// Style or class?
			//
			if (ch == '(') {
				style = readBraceContent('(', ')');
			} else if (Character.isLetter((char) ch)) {
				pushBack(ch);
				clazz = readUntil("( \t\n\r");
				// Note: ref.https://www.w3.org/TR/CSS21/syndata.html#characters
				// CSS Classnames can contain only the characters [a-zA-Z0-9] and
				// ISO 10646 characters U+00A0 and higher, plus the "-" and the "_".
				// They cannot start with a digit, two hyphens, or a hyphen followed by a digit.

				// (1) replace '.' by spaces, allowing multiple classnames on a div or span
				// (2) remove any invalid character
				if (clazz != null) {

					clazz = clazz.replace('.', ' ').replaceAll("[^\\s-_\\w\\x200-\\x377]+", "");

				}
				ch = nextToken();

				// check for %%class1.class2( style information )
				if (ch == '(') {
					style = readBraceContent('(', ')');
				}
				//
				// Вытаскивать только пробелы, чтобы предстоящая проверка EOL не проверяла следующую строку.
				//
				else if (ch == '\n' || ch == '\r') {
					pushBack(ch);
				}
			} else {
				//
				// Все остальное прекращается.
				//
				pushBack(ch);

				try {
					Boolean isSpan = m_styleStack.pop();

					if (isSpan == null) {
						// Fail quietly
					} else if (isSpan.booleanValue()) {
						el = popElement("span");
					} else {
						el = popElement("div");
					}
				} catch (EmptyStackException e) {
					log.debug("Page '" + RWC.INSTANCE.getName() + "' closes a %%-block that has not been opened.");
					return m_currentElement;
				}

				return el;
			}

			//
			// Проверить, нет ли попытки сделать что-то гадкое.
			//

			try {
				style = StringEscapeUtils.unescapeHtml4(style);
				if (style != null && style.indexOf("javascript:") != -1) {
					log.debug("Attempt to output javascript within CSS:" + style);
					ResourceBundle rb = Preferences.getBundle();
					return addElement(makeError(rb.getString("markupparser.error.javascriptattempt")));
				}
			} catch (NumberFormatException e) {
				//
				// Если есть неизвестные объекты, мы не хотим, чтобы парсер останавливался.
				//
				ResourceBundle rb = Preferences.getBundle();
				String msg = MessageFormat.format(rb.getString("markupparser.error.parserfailure"), e.getMessage());
				return addElement(makeError(msg));
			}

			//
			// Решает, следует ли нам открыть div или span?
			//
			String eol = peekAheadLine();

			if (eol.trim().length() > 0) {
				// Что-то есть после class.

				el = new Element("span");

				m_styleStack.push(Boolean.TRUE);
			} else {
				startBlockLevel();
				el = new Element("div");
				m_styleStack.push(Boolean.FALSE);
			}

			if (style != null) {
				el.setAttribute("style", style);
			}
			if (clazz != null) {
				el.setAttribute("class", clazz);
			}
			el = pushElement(el);

			return el;
		}

		pushBack(ch);

		return el;
	}

	private Element handleSlash(boolean newLine) throws IOException {
		int ch = nextToken();

		pushBack(ch);
		if (ch == '%' && !m_styleStack.isEmpty()) {
			return handleDiv(newLine);
		}

		return null;
	}

	private Element handleBar(boolean newLine) throws IOException {
		Element el = null;

		if (!m_istable && !newLine) {
			return null;
		}

		//
		// Если '|' находится в первой позиции входной строки,
		// то либо начать новую таблицу, либо продолжить старую.
		//

		if (newLine) {
			if (!m_istable) {
				startBlockLevel();
				el = pushElement(new Element("table").setAttribute("class", "wikitable").setAttribute("border", "1"));
				m_istable = true;
				m_rowNum = 0;
			}

			m_rowNum++;
			Element tr = (m_rowNum % 2 != 0) ? new Element("tr").setAttribute("class", "odd") : new Element("tr");
			el = pushElement(tr);
		}

		//
		// Проверить, какой элемент ячейки таблицы начать:
		// элемент заголовка (th) или обычный элемент (td).
		//
		int ch = nextToken();

		if (ch == '|') {
			if (!newLine) {
				el = popElement("th");
				if (el == null) {
					popElement("td");
				}
			}
			el = pushElement(new Element("th"));
		} else {
			if (!newLine) {
				el = popElement("td");
				if (el == null) {
					popElement("th");
				}
			}

			el = pushElement(new Element("td"));

			pushBack(ch);
		}

		return el;
	}

	/**
	 * Общее экранирование следующего символа или сущности.
	 */
	private Element handleTilde() throws IOException {
		int ch = nextToken();

		if (ch == ' ') {
			if (m_wysiwygEditorMode) {
				m_plainTextBuf.append("~ ");
			}
			return m_currentElement;
		}

		if (ch == '|' || ch == '~' || ch == '\\' || ch == '*' || ch == '#' || ch == '-' || ch == '!' || ch == '\''
				|| ch == '_' || ch == '[' || ch == '{' || ch == ']' || ch == '}' || ch == '%') {
			if (m_wysiwygEditorMode) {
				m_plainTextBuf.append('~');
			}

			m_plainTextBuf.append((char) ch);
			m_plainTextBuf.append(readWhile("" + (char) ch));
			return m_currentElement;
		}

		// Не экранировать.
		pushBack(ch);

		return null;
	}

	private void fillBuffer(Element startElement) throws IOException {
		m_currentElement = startElement;

		boolean quitReading = false;
		m_newLine = true;
		disableOutputEscaping();

		while (!quitReading) {
			int ch = nextToken();

			if (ch == -1) {
				break;
			}

			//
			// Проверить, действительно ли мы завершаем режим предварительного форматирования.
			// Здесь все еще следует выполнить преобразование сущности.
			//
			if (m_isEscaping) {
				if (ch == '}') {
					if (handleClosebrace() == null) {
						m_plainTextBuf.append((char) ch);
					}
				} else if (ch == -1) {
					quitReading = true;
				} else if (ch == '\r') {
					// DOS-переводы строк игнорируются.
				} else if (ch == '<') {
					m_plainTextBuf.append("&lt;");
				} else if (ch == '>') {
					m_plainTextBuf.append("&gt;");
				} else if (ch == '&') {
					m_plainTextBuf.append("&amp;");
				} else if (ch == '~') {
					String braces = readWhile("}");
					if (braces.length() >= 3) {
						m_plainTextBuf.append("}}}");

						braces = braces.substring(3);
					} else {
						m_plainTextBuf.append((char) ch);
					}

					for (int i = braces.length() - 1; i >= 0; i--) {
						pushBack(braces.charAt(i));
					}
				} else {
					m_plainTextBuf.append((char) ch);
				}

				continue;
			}

			//
			// Пустая строка останавливает список.
			//
			if (m_newLine && ch != '*' && ch != '#' && ch != ' ' && m_genlistlevel > 0) {
				m_plainTextBuf.append(unwindGeneralList());
			}

			if (m_newLine && ch != '|' && m_istable) {
				popElement("table");
				m_istable = false;
			}

			//
			// Выполнить фактический синтаксический анализ и отловить все ошибки.
			//
			try {
				//
				// Идея заключается в следующем: если метод-обработчик возвращает элемент (el != null),
				// то предполагается, что он был добавлен в стек.
				// В противном случае символ добавляется в буфер исходного текста как есть.
				//
				// Для переходной фазы, если s != null, он также добавляется в буфер исходного текста.
				//

				switch (parseToken(ch)) {
				case ELEMENT:
					m_newLine = false;
					break;

				case CHARACTER:
					m_plainTextBuf.append((char) ch);
					m_newLine = false;
					break;

				case IGNORE:
				default:
					break;
				}
			} catch (IllegalDataException e) {
				log.info("Page " + RWC.INSTANCE.getPage().getName()
						+ " contains data which cannot be added to DOM tree: " + e.getMessage());

				makeError("Error: " + cleanupSuspectData(e.getMessage()));
			}
		}

		closeHeadings();
		popElement("domroot");
	}

	private String cleanupSuspectData(String s) {
		StringBuilder sb = new StringBuilder(s.length());

		for (int i = 0; i < s.length(); i++) {
			char c = s.charAt(i);

			if (Verifier.isXMLCharacter(c)) {
				sb.append(c);
			} else {
				sb.append("0x" + Integer.toString(c, 16).toUpperCase());
			}
		}

		return sb.toString();
	}

	enum TokenKind {
		/** Токен - простой символ. */
		CHARACTER,
		/** Токен - элемент вики-разметки. */
		ELEMENT,
		/** Токен следует игнорировать. */
		IGNORE
	}

	/** Токен представляет собой простой символ. */
//	protected static final int CHARACTER = 0;

	/** Этот токен - элемент вики-разметки. */
//	protected static final int ELEMENT = 1;

	/** Этот токен следует игнорировать. */
//	protected static final int IGNORE = 2;

	/**
	 * Возвращает:
	 * <ul>
	 * <li>CHARACTER, если считает, что это был обычный символ;</li>
	 * <li>ELEMENT, если считает, что это был элемент вики-разметки;</li>
	 * <li>IGNORE, если считает, что мы должны полностью игнорировать это.</li>
	 * </ul>
	 * <p>
	 * Для добавления своего собственного MarkupParser, вы можете переопределить этот метод, но
	 * рекомендуется также вызвать super.parseToken(), чтобы получить преимущества от собственной
	 * разметки JSPWiki.</br>
	 * Можно вызвать его в начале вашего parseToken() или в конце – это не имеет значения.
	 *
	 * @param ch Исследуемый сивол.
	 * @return {@link #ELEMENT}, {@link #CHARACTER} or {@link #IGNORE}.
	 * @throws IOException если что-то пошло не так, как ожидалось.
	 */
	protected TokenKind parseToken(int ch) throws IOException {
		Element el = null;

		//
		// Теперь проверить входящий токен.
		//
		switch (ch) {
		case '\r':
			// DOS-переводы DOS, которые игнорируются.
			return TokenKind.IGNORE;

		case '\n':
			//
			// Закрывать такие элементы, как заголовки и т.д.
			//

			// FIXME: На самом деле это не очень быстро

			closeHeadings();

			popElement("dl"); // Закрыть списки определений.
			if (m_istable) {
				popElement("tr");
			}

			m_isdefinition = false;

			if (m_newLine) {
				// Изменение абзаца (paragraph).
				startBlockLevel();

				//
				// Выяснить, какие элементы не могут быть заключены в пару <p></p>, согласно правилам XHTML.
				//
				String nextLine = peekAheadLine();
				if (nextLine.length() == 0
						|| (nextLine.length() > 0 && !nextLine.startsWith("{{{") && !nextLine.startsWith("----")
								&& !nextLine.startsWith("%%") && "*#!;".indexOf(nextLine.charAt(0)) == -1)) {
					pushElement(new Element("p"));
					m_isOpenParagraph = true;

					if (m_restartitalic) {
						pushElement(new Element("i"));
						m_isitalic = true;
						m_restartitalic = false;
					}
					if (m_restartbold) {
						pushElement(new Element("b"));
						m_isbold = true;
						m_restartbold = false;
					}
				}
			} else {
				m_plainTextBuf.append("\n");
				m_newLine = true;
			}
			return TokenKind.IGNORE;

		case '\\':
			el = handleBackslash();
			break;

		case '_':
			el = handleUnderscore();
			break;

		case '\'':
			el = handleApostrophe();
			break;

		case '{':
			el = handleOpenbrace(m_newLine);
			break;

		case '}':
			el = handleClosebrace();
			break;

		case '-':
			if (m_newLine) {
				el = handleDash();
			}

			break;

		case '!':
			if (m_newLine) {
				el = handleHeading();
			}
			break;

		case ';':
			if (m_newLine) {
				el = handleDefinitionList();
			}
			break;

		case ':':
			if (m_isdefinition) {
				popElement("dt");
				el = pushElement(new Element("dd"));
				m_isdefinition = false;
			}
			break;

		case '[':
			el = handleOpenbracket();
			break;

		case '*':
			if (m_newLine) {
				pushBack('*');
				el = handleGeneralList();
			}
			break;

		case '#':
			if (m_newLine) {
				pushBack('#');
				el = handleGeneralList();
			}
			break;

		case '|':
			el = handleBar(m_newLine);
			break;

		case '~':
			el = handleTilde();
			break;

		case '%':
			el = handleDiv(m_newLine);
			break;

		case '/':
			el = handleSlash(m_newLine);
			break;

		default:
			break;
		}

		return el != null ? TokenKind.ELEMENT : TokenKind.CHARACTER;
	}

	private void closeHeadings() {
		if (m_lastHeading != null && !m_wysiwygEditorMode) {
			// Добавить элемент привязки хеша в конце заголовка.
			addElement(new Element("a").setAttribute("class", HASHLINK)
					.setAttribute("href", "#" + m_lastHeading.m_titleAnchor).setText("#"));
			m_lastHeading = null;
		}
		popElement("h2");
		popElement("h3");
		popElement("h4");
	}

	/**
	 * Парсит весь документ из Reader, заданного в конструкторе или установленного
	 * {@link #setInputReader(Reader)}.
	 *
	 * @return WikiDocument, готовый к передаче в средство рендеринга.
	 * @throws IOException Если синтаксический анализ не может быть выполнен.
	 */
	@Override
	public WikiDocument parse() throws IOException {
		WikiDocument d = new WikiDocument(RWC.INSTANCE.getPage());

		try {
			Element rootElement = new Element("domroot");

			d.setRootElement(rootElement);

			fillBuffer(rootElement);

			paragraphify(rootElement);
		} catch (IOException ex) {
			throw ex;
		} catch (Exception ex) {
			log.error("Unexpected error:", ex);
			// В результирующий WikiDocument размещается информация об неожиданной ошибке парсинга.
			try (StringWriter writer = new StringWriter(); PrintWriter printWriter = new PrintWriter(writer);) {
				Element parentEl = new Element("text");
				Element error = new Element("span");
				error.addContent(new Date().toString() + " - Error:");
				error.setAttribute("style", """
						background-color: #f2dede; color: #a94442; border-radius: 4px;
						display: flex; align-items: center; height:30pt;
						""");
				parentEl.addContent(error);

				Element paraStTrace = new Element("p").setAttribute("style", "margin-left:40pt;");
				parentEl.addContent(paraStTrace);

				ex.printStackTrace(printWriter);
				BufferedReader br = new BufferedReader(new StringReader(writer.toString()));
				String line;
				int counter = 10;
				while ((line = br.readLine()) != null && counter-- > 0) {
					paraStTrace.addContent(new Text(line));
					paraStTrace.addContent(new Element("br"));
				}

				Element rootElement = new Element("domroot");
				d.setRootElement(rootElement);
				rootElement.addContent(parentEl);
			}
		}

		return d;
	}

	/**
	 * Проверяет правильность установки первого абзаца (paragraph).
	 *
	 * @param rootElement
	 */
	private void paragraphify(Element rootElement) {
		//
		// Добавить тег paragraph к первому абзацу.
		//
		List<Content> kids = rootElement.getContent();

		if (rootElement.getChild("p") != null) {
			ArrayList<Content> ls = new ArrayList<>();
			int idxOfFirstContent = 0;
			int count = 0;

			for (Iterator<Content> i = kids.iterator(); i.hasNext(); count++) {
				Content c = i.next();
				if (c instanceof Element element) {
					String name = element.getName();
					if (isBlockLevel(name)) {
						break;
					}
				}

				if (!(c instanceof ProcessingInstruction)) {
					ls.add(c);
					if (idxOfFirstContent == 0) {
						idxOfFirstContent = count;
					}
				}
			}

			//
			// Если были какие-либо элементы, добавить новый <p> (если только он не будет пустым)
			//
			if (ls.size() > 0) {
				Element newel = new Element("p");

				for (Iterator<Content> i = ls.iterator(); i.hasNext();) {
					Content c = i.next();

					c.detach();
					newel.addContent(c);
				}

				//
				// Убедиться, что не добавлено пустых тегов <p/>.
				//
				if (newel.getTextTrim().length() > 0 || !newel.getChildren().isEmpty()) {
					rootElement.addContent(idxOfFirstContent, newel);
				}
			}
		}
	}

}
